<?php

/**
*   The quasi-JSON-format parser
*
*/
class Yz_Config_Loader extends Yz_Class
{

    const T_END = 0;
    const T_KEY = 1;
    const T_VALUE = 2;
    const T_SEPARATOR = 3;

    protected static $instance;

    protected $source;

    protected $string;
    protected $index = 0;
    protected $length = 0;
    protected $value = null;

    protected $lineNo = 1;
    protected $charNo = 0;
    protected $char;

    /**
    *   @var string templateHash : the class of ArrayAccess object used for %Hash
    */
    protected $templateHash = 'Yz_Hash';



    /*****************************
     *      Public methods       *
     ****************************/

    /** Parse a string into a `configuration' object (optionally specified by $start)
     *
     *  @param string $string
     *  @param string $sourceName
     *  @param ArrayAccess $start the type of return value, by default it is Yz_Hash
     *      NOTE: If start is supplied, the values will be appended to it
     *  @return ArrayAccess
     */
    public static function load( $string, $sourceName, ArrayAccess $start=null )
    {
        $i =& self::$instance;
        if (null === $i) {
            $i = new self();
        }
        return $i->doload($string, $sourceName, $start);
    }


    /*****************************
     *    Protected methods      *
     ****************************/


    /** protected constructor
     *
     */
    protected function __construct()
    {
        return;
    }

    /**
    *   @return ArrayAccess
    */
    protected function doload( $string, $sourceName, ArrayAccess $start=null )
    {

        $this->source = $sourceName;
        $this->string = $string;
        $this->index = 0;
        $this->length = strlen( $string );
        $this->lineNo = 1;

        if ( $start instanceof ArrayAccess ) {
            $this->templateHash = get_class($start);
        } else {
            $start = new Yz_Hash();
        } // if

        $allowEnd = true; // EOF is the end of the top level hash
        // if the config starts with {, then EOF not allowed
        $token = $this->ignoreSpaceAndComments( true );
        if ( "{" === $token ) {

            // now a closing brace is required
            // EOF is NOT the end of the top level hash, rather an error
            $allowEnd = false;
            // done with {
            $this->nextChar();
        }
        // else // it is already true
        //    $allowEnd = true;
        if (null === $token) {
            $value = $start;
        } else {
            $value = $this->beginHash( $start, $allowEnd );
        }

        return $value;
    } // load


    /**
    *   fetch the char at the current position
    *   @return string char | false if EOF
    *   @throw Yz_Config_Exception if unexpected end-of-source
    */
    protected function fetchChar()
    {
        $c = substr($this->string, $this->index, 1);
        $this->char = $c;
        return $c;
    } // fetchChar


    /**
    *   fetch the char at the position current+n
    *   @param bool(false) allowEnd : handle end-of-source
    *       if true, return null on end-of-source, if false, throw exception
    *   @return string char | null if EOF
    *   @throw Yz_Config_Exception if unexpected end-of-source
    */
    protected function fetchCharAt($pos)
    {
        $c = substr($this->string, $this->index+$pos, 1);
        return $c;
    } // fetchChar


    /**
    *   (expected to be called after fetchChar())
    *   advance the current char position in the source
    *   handle line numbers
    *   @return (ignored)
    */
    protected function nextChar()
    {
        $c = $this->char; //substr($this->string, $this->index, 1);
        $this->index ++;
        if( "\n" === $c ) {
            $this->charNo = 0;
            $this->lineNo ++;
        } else {
            $this->charNo ++;
        }
        return $this;
    } // nextChar


    /**
    *   skip spaces with options up to the end-of-source or EOL
    *   @param bool(false) allowEnd : @see fetchChar()
    *   @param bool(true)  ignoreNewline : true=newline (\n) is treated as space, false=stop at the newline
    *   @return string first non-space and non-comment char | null
    *   @throw Yz_Config_Exception if unexpected end-of-source
    */
    protected function ignoreSpaceAndComments( $ignoreNewline=true )
    {
        $inComment = false; // set after single-line comment tokens # or // until end-of-line
        $multiline = false; // set when within C-style comments /* */
        while (true) {
            $c = $this->fetchChar();
            if (false === $c) {
                return null;
            }
            if ($multiline) { // within C-style comments /* */

                // only */ counts, everything else is ignored
                if ('*' === $c && '/' === $this->fetchCharAt(1)) {
                    $this->nextChar();
                    $multiline = false;
                }
            } else if ( "\n" === $c ) {
                if (! $ignoreNewline) {
                    return $c;
                }
                $inComment = false;
            } else if (! $inComment) {
                if ("#" === $c) {
                    $inComment = true;
                } else if ('/' === $c) { // C-style comments
                    $c1 = $this->fetchCharAt(1);
                    switch($c1) {
                        case '*':   // C-style comments /* */
                            $multiline = true;
                            // needn't set inComment
                            break;
                        case '/':   // C-style comments //
                            $inComment = true;
                            break;
                        default:
                            return $c;
                    }
                    $this->nextChar();
                } else if (strpos(" \r\0\t", $c) === false) {
                    return $c;
                }
            }
            $this->nextChar();
        }
        return null;
    } // ignoreSpaceAndComments


    /** state "begin" - a value begins
    *   @return null|Yz_Hash|Yz_Array|<string>
    */
    protected function beginValue()
    {
        // end-of-string allowed ?
        $token = $this->ignoreSpaceAndComments( true );
        if( null === $token ) {
            return null;
        }

        switch( $token ) {
            case "[":    // start of list
                $this->nextChar();
                return $this->beginList( new Yz_Array );

            case "{":    // start of hash
                $this->nextChar();
                return $this->beginHash( new $this->templateHash(), false );

            default:
                return $this->beginString();
        }
    } // beginValue


    // state "beginList" - parse a list value
    protected function beginList( ArrayAccess $value )
    {

        $separator = false;

        while( true ) {

            // don't ignore end of source and newlines
            $token = $this->ignoreSpaceAndComments( false );

            // end of list
            if( "]" === $token ) {
                $this->nextChar();
                break;
            }

            if ( "\n" === $token ) {

                // if a newline had been encountered, separation by comma is no longer required
                $separator = false;
                $this->nextChar();
            } else if ( "," === $token ) {

                if( ! $separator ) {
                    $this->raiseError( 'Unexpected comma' );
                }
                // search for next value
                $this->nextChar();
            } else {

                // expect: value (list,hash,string,rawstring) or end-of-list
                $gottenValue = $this->beginValue(); // char pointer is at the next character after value
                $value->push( $gottenValue );
                $separator = true;
            }
        }

        return $value;
    } // beginList


    /** state "beginHash" - parse a hash value
    *   @param ArrayAccess value: the hash key-value
    *   @param bool $allowEnd : whether EOF is treated as end of Hash (true) or as an error
    */
    protected function beginHash( ArrayAccess $value, $allowEnd=false )
    {

        $expect = self::T_KEY;
        $expectEndOfHash = true;

        $ignoreNewline = true;

        while ( true ) {

            $token = $this->ignoreSpaceAndComments( $ignoreNewline );

            // EOF, only possible when $allowEnd==true
            // ( otherwise the previous statement will throw an exception)
            if( null === $token ) {
                if (! $allowEnd) {
                    $this->raiseError( 'Unexpected EOF' );
                }
                break;
            }

            // end-of-hash:
            if( "}" === $token ) {
                //
                if ( ! $expectEndOfHash ) {
                    $this->raiseError( 'Unexpected end of hash' );
                }
                $this->nextChar();
                break;
            }


            switch( $expect ) {
                case self::T_KEY:
                    $key = $this->fetchHashKey(); // char pointer is at the next character after key:
                    $ignoreNewline = true;
                    $expect = self::T_VALUE;
                    $expectEndOfHash = false;
                    break;

                case self::T_SEPARATOR:
                    if ( "," === $token || "\n" === $token ) {
                        $ignoreNewline = true;
                        $this->nextChar();
                        $expect = self::T_KEY;
                        $expectEndOfHash = "\n" === $token;
                        break;
                    }
                    // error: unexpected symbol
                    $this->raiseError( "Unexpected symbol '$token', expecting separator" );
                    break 2;

                case self::T_VALUE:
                    $gottenValue = $this->beginValue(); // char pointer is at the next character after value

                    $value[$key] = $gottenValue;

                    $key = null;
                    $expect = self::T_SEPARATOR;
                    $ignoreNewline = false; // a newline counts as a separator
                    $expectEndOfHash = true;
                    break;

            } // end sw

        } // end while
        return $value;

    } // beginHash


    protected function fetchHashKey()
    {
        $key = $this->beginString();

        if( ! is_string($key) || "" === $key ) {
            $this->raiseError( 'Invalid value for hash key' );
        }
        $colon = $this->fetchChar();
        if ( ":" !== $colon ) {
            $this->raiseError( 'missing colon' );
        }
        $this->nextChar();
        return $key;
    } // fetchHashKey


    // state "begin" - a value begins
    protected function beginString()
    {

        $token = $this->fetchChar();
        switch( $token ) {
            case "'":    // start of delimited string
            case "`":    // start of delimited string
            case '"':    // start of delimited string
                $this->nextChar();
                $value = $this->fetchDelimitedString($token);
                break;
            default:
                $value = $this->fetchRawString();
        }

#        debug( "gotten value = '$value'" );
        return $value;
    } // beginString


    /**
    *   fetch a valid utf-8 string starting at $this->index
    */
    protected function fetchRawString()
    {
        $str = substr( $this->string, $this->index );
        if ( preg_match( '~^[\w.-]+~u', $str, $m ) ) {
            $value = $m[0];
            $inc = strlen($value);
            $this->index += $inc;
            $this->charNo += $inc;
        } else {
            $this->raiseError( 'Non-delimited value contains disallowed characters' );
        }
        if ("null" === strtolower( $value )) {
            $value = null;
        } else {
            $bool = Yz_String::isTrue($value);
            if (is_bool($bool)) {
                $value = $bool;
            }
        }
        /*
        switch ( strtolower( $value ) ) {
            case "null":
                $value = null;
                break;
            case "false":
                $value = false;
                break;
            case "true":
                $value = true;
                break;
        }
        */
        return $value;
    }


    /**
    *   fetch a valid utf-8 string starting at $this->index
    */
    protected function fetchDelimitedString( $delimiter )
    {
        #$i = $this->index;
        $pos = strpos($this->string, $delimiter, $this->index);
        if (false === $pos) {
            $this->raiseError("Not found matching delimiter");
        }
        $value = substr($this->string, $this->index, $pos-$this->index);
//TODO parse value for \" escapes (if last char is \ ...)
//TODO correct lineNo & charNo
            // TODO support \x escaping in "string"s
            // todo handle eof
        $this->index = $pos+1;
        return $value;
    }


    protected function raiseError($reason)
    {
        $ex = new Yz_Config_Exception();
        throw $ex
            ->setCause( $reason )
            ->setSourceName( $this->source )
            ->setLineNr( $this->lineNo )
            ->setCharNr( $this->charNo );
    }

} // end class

